Close the Slack gate's flag-repetition bypass, and stop a NUL byte from silencing the bot - #72
Merged
Merged
Conversation
…om silencing the bot
Two defects in `grapharc/slack/command.py`, the module that decides what text
from a Slack workspace may become an argv.
**Typing a flag twice walked past the agent opt-in.** The gate admits a command
by reading a flag's value; argparse's `store` action then runs the *last*
occurrence, and `_flag_value` returned the *first*. So the gate and the CLI read
different values out of the same command line:
plan 'ship it' --model openrouter/x/y \
--registry grapharc.examples.plan_docs:build_registry \
--registry grapharc.stdlib:build_registry
was judged against the demo registry — which needs no opt-in — and executed
against `grapharc.stdlib:build_registry`, which builds agent kinds that run
tools on the host under an executor that, in its own words, "confines nothing".
`GRAPHARC_SLACK_ALLOW_AGENT` was never consulted, and the `--approve` injection
that would have put a human in front of the run was skipped in the same step,
so nothing downstream caught it either. The single-flag form was refused
correctly the whole time; the exploit was the second `--registry`, which needs
no privilege and no special knowledge.
Repeats of any admitted flag are refused outright now. That is the fail-closed
reading and it retires the whole first-vs-last family rather than the one flag
that exposed it: a Slack command has no legitimate reason to pass `--registry`
or `--model` twice, and a gate that must choose which of two occurrences to
believe is a gate that can be wrong. The carve-out is `repeatable_flags`, the
options the CLI itself accumulates (`agent --allow`/`--deny`, argparse
`action="append"`), where every occurrence reaches the run and there is no other
value to diverge from. A duplicated `--model` falls to the same rule, opted in
or not, so it cannot smuggle a backend past the spend gate.
`_flag_value` reads the last occurrence regardless — the cheap half of the belt.
With repeats refused there is only ever one, but a future caller assembling an
argv some other way should not be able to reopen the gap. A sweep over the whole
allowlist asserts the duplicated form of every gated flag, so a gate added later
inherits the property instead of having to remember it.
**A NUL byte in a path came back as silence.** `Path(raw).resolve()` raises
`ValueError`, and `handle_text_live` catches only `SlackCommandError`, so
`trace a\x00b` escaped the bolt listener as an unhandled exception and the
requester got no reply at all — the one answer a chat bot must never give, since
it is indistinguishable from the bot being down. A NUL anywhere in the request
is a refusal now, in the same voice the core tools already use ("cannot name a
file"), and `_confined` turns any `ValueError`/`OSError` out of the filesystem
into a refusal too, keeping the guarantee for callers of its own.
Folded in from the same report: the flag allowlist tested
`token.startswith("--")`, so a single-dash token slipped it and was spent as a
positional — `trace -h` was admitted with `-h` as the path. The allowlist is
meant to be exhaustive; any leading dash is a flag now, and one not on the list
is refused like any other.
Fixes #61
Fixes #64
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two defects in
grapharc/slack/command.py, the module that decides what text from a Slack workspace may become an argv. Same file, same class of mistake — a gate that reads one thing while the CLI runs another, and a refusal path that raises instead of replying.#61 — repeating
--registrywalked past the agent opt-inThe gate admits a command by reading a flag's value; argparse's
storeaction then runs the last occurrence, and_flag_value(command.py:331) returned the first. So the two read different values out of the same command line:Judged against the demo registry, which needs no opt-in; executed against
grapharc.stdlib:build_registry, which builds agent kinds that run tools on the host under an executor that, in its own words, "confines nothing".GRAPHARC_SLACK_ALLOW_AGENTwas never consulted, and the--approveinjection that would have put a human in front of the run was skipped in the same step, so nothing downstream caught it either. The exploit is the second--registry: no privilege, no special knowledge.Fix — the issue's second option, fail-closed: a repeated flag is refused outright. That retires the whole first-vs-last family rather than the one flag that exposed it; a Slack command has no legitimate reason to pass
--registryor--modeltwice, and a gate that must pick which of two occurrences to believe is a gate that can be wrong. The carve-out is a newCommandSpec.repeatable_flags— the options the CLI itself accumulates (agent --allow/--deny, argparseaction="append"), where every occurrence reaches the run and there is no other value to diverge from. A duplicated--modelfalls to the same rule, opted in or not, so it cannot smuggle a backend past the spend gate either._flag_valuenow reads the last occurrence regardless — the cheap half of the belt. With repeats refused there is only ever one, but a future caller assembling an argv some other way should not be able to reopen the gap.After:
#64 — a NUL byte came back as silence
Path(raw).resolve()(command.py:171) raisesValueError, andhandle_text_livecatches onlySlackCommandError, sohandle_text("trace a\x00b", cfg)escaped the bolt listener as an unhandled exception and the requester got no reply at all — the one answer a chat bot must never give, since it is indistinguishable from the bot being down.A NUL anywhere in the request is a refusal now, in the same voice the core tools already use (
workspace.py: "cannot name a file"), and_confinedturns anyValueError/OSErrorout of the filesystem into a refusal too, keeping the guarantee for callers of its own.Folded in from the same issue: the flag allowlist tested
token.startswith("--")(command.py:222), so a single-dash token slipped it and was spent as a positional. The allowlist is meant to be exhaustive; any leading dash is a flag now, and one not on the list is refused like any other.Before / after:
Tests
Six added to
tests/test_slack_gateway.py; the five that cover the defects were confirmed failing on the pre-fixcommand.py(stashed) and passing after.test_a_repeated_registry_cannot_walk_the_agent_opt_in— the issue's repro, in both orders, so neither "the gate reads the last" nor "the gate reads the first" can pass it again; asserts the single-flag refusal and the single-flag admission are unchanged.test_a_repeated_model_cannot_smuggle_a_backend_past_the_spend_gate— opted in and not, and the--model x --model=ymixed spelling.test_every_gated_flag_is_refused_in_its_duplicated_form— sweepsALLOWED_COMMANDSand asserts the duplicated form of every admitted, non-repeatable flag, so a gate added later inherits the property instead of having to remember it (the issue's "add a test that every gated flag is checked in its duplicated form").test_the_flags_the_cli_accumulates_stay_repeatable—agent --allow/--denystill accumulate.test_a_nul_byte_is_a_refusal_not_an_exception— positional, path flag and free-text forms throughparse_command, the issue'shandle_textrepro, and_confineddirectly.test_a_single_dash_token_is_refused_as_a_flag_not_taken_as_a_path.CHANGELOG.mdgets an entry per defect (Unreleased, newest-last), and the admission table indocs/cookbook/07-slack.mdgets the no-repeat row.Verification
Full
pytestgreen andruff check .clean, on the same worktree that ran green on untouchedmainfirst — no flaky failures showed up in either run, SIGALRM timing tests included.Fixes #61
Fixes #64
🤖 Generated with Claude Code